fix(types): give seven recursion-breaking zod mirrors their declaration as both type arguments - #8354
Merged
Merged
Conversation
…on as both type arguments The ten `z.lazy` mirrors annotated `z.ZodType<any>` publish `unknown` as their static INPUT face, because zod 4 defaults that parameter when the argument is `any`. Every slot spelled through one of them therefore told an author nothing: `unknown` is wider than every declaration by definition and silent about what the mirror accepts at runtime, where the lazy union does validate. Seven now carry their existing TypeScript declaration in both positions — `SchemaNodeSchema`, `zod/app`'s `MenuItemSchema`, `ActionSchema`, `TreeNodeSchema`, `NavLinkSchema`, `NavigationMenuItemSchema` and `zod/overlay`'s `MenuItemSchema`. The `z.lazy` bodies, the TS declarations and `@objectstack/spec` are untouched, so no runtime accept set moves. Three refused the argument, each measured one const at a time: `NavigationItemSchema` (`_output.id`: `string | undefined` against a required `string`), `FilterBuilderConditionSchema` (`_output.operator`: `is_null` / `is_not_null` against `FilterBuilderOperator`) and, transitively through its `conditions` arm, `FilterGroupSchema`. Each is a mirror that already accepts more than its declaration states, so filling the argument IS that comparison and `tsc` refuses the assignment. None of the ten re-closed an inference cycle. The parity ledger (objectui#7069, PR #7758) can now see the region it had declared EXCLUDED: `WiderThanDeclared` goes 34 entries / 52 keys / 61 arms to 23 / 36 / 47, its SCHEMA-NODE class 24 keys to 6, and three real widenings the erased face had been hiding enter it — `ToastSchema.action`, `HeaderBarSchema.logo` and `TooltipSchema.content`, all three the `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` single-or-list spelling that card called systematic. `KnownDrift` gains `ToastSchema.action` from the other side. The runtime walk leg is unchanged and still bounds the sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
…y-mirror-input-type-args
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Sep 7, 2026
Open
This was referenced Sep 7, 2026
…ad/cast vector
Contract review at CONTRACT_REVIEW_TIER returned FAIL on the changeset only; the
code increment was judged sound and is untouched by this commit.
Two reproducible factual defects, both fixed here:
1. The prior output face was misstated, so the breakage vector was under-reported.
`z.ZodType<any>` resolves to `ZodType<any, unknown>` (zod 4.4.3,
`v4/classic/schemas.d.ts`: `interface ZodType<out Output = unknown,
out Input = unknown, ...>`). So `z.input` read `unknown` — the changeset had
that right — but `z.infer` / `z.output` read `any`, not `unknown`. The stated
breakage mode ("code that assigned an arbitrary value into such a slot")
therefore covered writes only; `any` to a declaration also breaks reads and
casts of parsed output. This PR hit exactly that vector in-repo (the TS2352 on
`app-action-onclick-refusal-6854.test.ts`), so external consumers of
`@object-ui/types/zod` were being told the lesser vector. Both vectors are now
named, with this PR's own TS2352 as the worked instance.
2. "Every package ... was rebuilt and type-checked against the change with no site
needing a repair" contradicted the PR body's own declared deviation, which
names one in-repo site that did need a repair. Corrected to match what happened.
No schema, test or code change: `.changeset/*.md` only.
Gates, exit captured before any pipe:
node scripts/check-changeset-presence.mjs — exit 0
node scripts/check-changeset-no-major.mjs — exit 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtGhnU3WnnWyiWeYQhw2aX
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This was referenced Sep 8, 2026
os-sales
added a commit
that referenced
this pull request
Sep 8, 2026
… amend #8222's stale live-figure guidance objectui#8458 — `zod-mirror-parity.test.ts`'s header said the objectui#7760 move took "19 keys across 16 pairs". Every other figure in that paragraph is read by a `headerFigures` pin and moved correctly with PR #8354; the pairs figure was the one no spelling reached, so it compiled and tested green while wrong. Re-derived rather than adopted: this file's own `ledgerEntryMembers('WiderThanDeclared')` was sliced out of the source, transpiled and run over the file at `645ecb98c` and `a480f797a` (PR #8354's parent and its merge), diffing entry -> key pairs. 19 keys left and 3 entered, across 18 distinct entries — 13 emptied, 5 reduced. Both movements close against figures already pinned at those revisions (52 - 19 + 3 = 36 keys; 34 - 13 + 2 = 23 entries). `16` is neither reading. The figure is now pinned, following the precedent of the "N of the registered pairs carry TYPE drift TODAY" spelling already in this file, and "pairs" is defined where it stands: every entry the move TOUCHED, with `emptied` as the subset that lost its whole content. The pin reads only the sentence — the movement is a HISTORICAL reading of two fixed trees, so nothing compares it to the live ledger. objectui#8248 — objectui#8222's exclusion note still called the seed decomposition a LIVE figure, which objectui#8243 made historical. Per the maintainer ruling (decision batch #71) the guidance clause is amended and the two measurement-record clauses are untouched; the record-vs-guidance rule is written once, in the file header. Test-only: the file is under `__tests__/`, excluded by the package build tsconfig and absent from its published `files`. Declared with an empty-frontmatter changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
os-sales
added a commit
that referenced
this pull request
Sep 8, 2026
… amend #8222's stale live-figure guidance objectui#8458 — `zod-mirror-parity.test.ts`'s header said the objectui#7760 move took "19 keys across 16 pairs". Every other figure in that paragraph is read by a `headerFigures` pin and moved correctly with PR #8354; the pairs figure was the one no spelling reached, so it compiled and tested green while wrong. Re-derived rather than adopted: this file's own `ledgerEntryMembers('WiderThanDeclared')` was sliced out of the source, transpiled and run over the file at `645ecb98c` and `a480f797a` (PR #8354's parent and its merge), diffing entry -> key pairs. 19 keys left and 3 entered, across 18 distinct entries — 13 emptied, 5 reduced. Both movements close against figures already pinned at those revisions (52 - 19 + 3 = 36 keys; 34 - 13 + 2 = 23 entries). `16` is neither reading. The figure is now pinned, following the precedent of the "N of the registered pairs carry TYPE drift TODAY" spelling already in this file, and "pairs" is defined where it stands: every entry the move TOUCHED, with `emptied` as the subset that lost its whole content. The pin reads only the sentence — the movement is a HISTORICAL reading of two fixed trees, so nothing compares it to the live ledger. objectui#8248 — objectui#8222's exclusion note still called the seed decomposition a LIVE figure, which objectui#8243 made historical. Per the maintainer ruling (decision batch #71) the guidance clause is amended and the two measurement-record clauses are untouched; the record-vs-guidance rule is written once, in the file header. Test-only: the file is under `__tests__/`, excluded by the package build tsconfig and absent from its published `files`. Declared with an empty-frontmatter changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7760
Executes the maintainer ruling of decision batch #69 (2026-09-07, reply verbatim 「同意」): the ten
z.lazymirrors annotatedz.ZodType<any>get their existing TypeScript declaration as both type arguments, so the published input face of every slot that reaches them carries the node union instead ofunknown. The runtime accept set does not move;@objectstack/specis untouched; the TS declarations are untouched.Ruled 乙 with 丙 as the per-const fallback, and the trial below is why that clause was written.
The trial, one const at a time
Each row is its own run: the const's annotation filled on an otherwise clean
origin/maintree, thentsc --noEmitoverpackages/types. Order-dependent refusals were re-run in the combined tree, and the verdict column is the combined result.base.zod.ts#SchemaNodeSchemaz.ZodType<SchemaNode, SchemaNode>app.zod.ts#MenuItemSchemaz.ZodType<AppMenuItem, AppMenuItem>crud.zod.ts#ActionSchemaz.ZodType<ActionDeclaration, ActionDeclaration>(the TS twin is also namedActionSchema, so it is imported under an alias)data-display.zod.ts#TreeNodeSchemaz.ZodType<TreeNode, TreeNode>navigation.zod.ts#NavLinkSchemaz.ZodType<NavLink, NavLink>navigation.zod.ts#NavigationMenuItemSchemaz.ZodType<NavigationMenuItem, NavigationMenuItem>overlay.zod.ts#MenuItemSchemaz.ZodType<MenuItem, MenuItem>app.zod.ts#NavigationItemSchemacomplex.zod.ts#FilterBuilderConditionSchemacomplex.zod.ts#FilterGroupSchemaThe three refusals, verbatim from
tsc:The ruling's stated cause for the fallback did not occur, and a different one did
The execution note conditions the 丙 fallback on the TS declaration being derived from the zod schema (
z.infer/z.input), which would re-close the inference cycle. That did not happen on any of the ten. All ten declarations are hand-writteninterface/type(checked: noz.inferorz.inputof a local mirror anywhere in the declaration files), and no run produced a circularity diagnostic.What the three refusals are instead: the mirror already accepts more than its declaration states, so the assignment
tscperforms to check the annotation is the wider-direction comparison, and it fails.NavigationItemSchema— the mirror declaresidoptional (deliberately, so a bare{ type: 'separator' }parses);NavigationItem.idis required.FilterBuilderConditionSchema— the mirror's operator enum carriesis_null/is_not_null;FilterBuilderOperatorcarriesis_empty/is_not_emptyinstead.FilterGroupSchema— itsconditionsunion namesFilterBuilderConditionSchema, so it follows the one above.Measured, not assumed: filling only the INPUT position (
z.ZodType<any, NavigationItem>) fails identically, on_input.idand_input.operator. Closing either would need a runtime accept-set change or a declaration change, both of which the ruling forbids — so these three keep the annotation and stay with 丙, which is where the ruling's fallback puts them. The trigger for that branch is wider than the ruling predicted, and this PR does not narrow it silently.The parity ledger can now see the region it had excluded
packages/types/src/__tests__/zod-mirror-parity.test.ts— the ledger from #7069 / PR #7758 — excluded every slot whose input face wasunknown, which is exactly thez.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])single-or-list producer that card called systematic. Re-run on this branch:5505aec)a26c3f9)WiderThanDeclaredentriesWiderThanDeclaredkeysWIDER_ARMSarmsKnownDriftentries / keysUnmirroredDeclared+RuntimeOnlyDeclaredThe "before" column is derived on this PR's base commit by the same instrument, not quoted from any card — and it reproduces the header's own figures exactly, which is what makes the "after" column readable.
19 keys across 16 pairs left (13 entries emptied): the reading they recorded was the annotation, not an accept-set gap. 3 keys entered, all three real widenings the erased face had been hiding, all three the single-or-list spelling:
feedback.zod.ts#ToastSchema::action— mirrorSchemaNode | SchemaNode[], declaration{ label: string; onClick: () => void }. DISJOINT, so it entersKnownDrifttoo. Note the declaration is the suspect face:ToastSchema.onDismisssits one member below it as a finding(types): 28 zod-mirror keys are declaredz.function(), which NO JSON document can satisfy — 14 in-repo corpus files author them and are silently invalid #6124 tombstone telling authors to "author behaviour as a node type", which is what the mirror already accepts.navigation.zod.ts#HeaderBarSchema::logo— mirrorSchemaNode | SchemaNode[], declarationlogo?: string.overlay.zod.ts#TooltipSchema::content— mirrorSchemaNode | SchemaNode[], declarationcontent?: string | SchemaNode: the same union without the list arm, while the siblingbodyon the same pair declares the list and agrees.None of the three is repaired here — the ruling's scope is the type argument. They are ledgered, with both faces named, for 丙's lane.
The runtime walk leg is unchanged and still pins the region's sources; its docblock now says why the list keeps all ten names even though seven no longer produce a blind face (it detects
z.lazynodes, which is what makes an eleventh visible).assertionSchemaNodeFaceIsUnconstrainedfired on the first compile, exactly as its own note said it would. It is kept, inverted, and a second line was added onNavigationItemSchemaso the exclusion's remaining members are pinned from the other side too — a revert of either annotation is red rather than silently unmeasured.Verification
pnpm --filter @object-ui/types type-check— exit 0 (three projects:tsc --noEmit,tsconfig.examples.json,tsconfig.test.json; the ledger's ratchets are compile-time assertions read by the third).vitest run --project unit packages/types/src/__tests__— 141 files, 2695 tests passed on the merged head.vitest run --project unit(whole repo, pre-merge head) — 960 files, 16230 passed, 2 skipped.turbo run build --concurrency=2 --filter='!@object-ui/site'— 43/43 successful.turbo run type-check --concurrency=2 --filter='...@object-ui/types'— 77/77 successful. Direction declared:...<pkg>is the package and its dependents, i.e. every downstream consumer of the changed published types.check:element-data-source-declaration·check:sdui-registration-pins·check:spec-symbols·check:spec-floors·check:doc-types·check:doc-snippets·check:doc-fences·check:readme-exports·check:control-bytes·check:handler-key-reads·check:self-import·check:phantom-deps— all exit 0.pnpm --filter @object-ui/types lintexit 0 (206 files linted, 0 errors, 266 pre-existingno-explicit-anywarnings — seven fewer than before, since sevenz.ZodType<any>annotations are gone) andpnpm run lint:rootexit 0. Narrowed deliberately: the diff touches one package plus.changeset/, and this repo'slintgate is per-package (turbo run lint), so the other 43 packages have no changed file;eslint.config.jsenables no type-aware linting (noproject/projectService), so nothing in this diff can move a verdict on a file it does not contain.Reverse verification (the published face really did narrow, read from the built
.d.ts)A consumer-side probe resolving
@object-ui/types/zodthrough the packageexports— so throughdist, not the source alias — assigning a function value intoz.input<typeof AppMenuItemSchema>:dist/zod/app.zod.d.tsblobtsccbfb6b4TS2559: Type '() => undefined' has no properties in common with type 'AppMenuItem'z.ZodType<any>, package rebuiltf03cc75The two dist blob hashes differ, so the second leg is a real rebuild and not a cache read. The ablation and probe were removed afterwards;
git statusandgit diff HEADare both empty and the dist blob is back tocbfb6b4.Declared deviation
packages/types/src/__tests__/app-action-onclick-refusal-6854.test.tsis outside the declared file surface and carries a one-line consequential edit.AppActionSchema.itemsused to infer asany[]because its element mirror was the erasedMenuItemSchema; it isAppMenuItem[]now, so a direct type assertion to{ items: Record<string, unknown>[] }no longer overlaps andtscrefuses it (TS2352). The repair routes that one assertion throughunknownand adds the reason. No assertion in the file changed, and the file's subject — that both undeclared keys are gone from the parsed value at runtime — is untouched. Reported rather than absorbed: without it the ruled change cannot compile at all.Serial constraints
#7918(same ten consts, different change) is CLOSED, completed by merged PR fix(types): memoise the two zod lazy getters that can be, pin why the other eight cannot (objectui#7918) #8226 (4f9f1ee, which memoised two of the lazy getters). This PR touches no getter — verified at contract review.prDeliversCardreport a delivery that does not exist and manufactures a phantom carrier-split row (objectstack#16706, measured with a two-sided control). Respelled here with zero verbs beside the card number.#8324and#8330consumeSchemaNodeSchemawithout editing any file on this surface.origin/mainwas merged in at411a132after both had landed,pnpm installre-run, and the fullpackages/typestype-check and test suite re-run green on the merge commit — including the ledger's ratchets against#7105's newEmptySchema.action: SchemaNodedeclaration.#7759is deferred behind this card; the parity ledger was held serially for exactly this reason.Review
packages/typesis not a governed surface (triage's correction, upheld by the ruling): ordinary merge queue. But this is a published type-face change, so clause ② applies:needs:contract-reviewis on the card and on this PR, the PR stays draft, and auto-merge is not armed. Only aCONTRACT_REVIEW_TIERreviewer clears the two carriers.Generated by Claude Code
Generated by Claude Code